ruby-mcp-client 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- metadata +68 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c8a27fd18f6a563bff801f562f4696e2432b0349e395b90cefe29d104e387ac1
|
|
4
|
+
data.tar.gz: c68c742a3fb269d8cdbf275745c749004827f3a9caefb9d25a585fd00ee90d62
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1bc1f3380f81ccf340bd3819abb90ce71e2233981de941bafcc0978636ee3e658514cacfd58f5f15d97f9d7adaaf4b24f144f0f33655aa75a8205386c2d6dfdc
|
|
7
|
+
data.tar.gz: 12192449542ad42e445b456fea3d122e84bd7ce6dcb6b8d2f2d9441e7351d01d888aac436469ea5bece83db20f7726f597e8a336e50d45be47554a24f7da7191
|
data/OAUTH.md
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
# OAuth 2.1 Support for Ruby MCP Client
|
|
2
|
+
|
|
3
|
+
This implementation provides OAuth 2.1 authentication support for the Ruby MCP Client, following the [MCP Authorization specification](https://spec.modelcontextprotocol.io/specification/protocol/authorization/).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **OAuth 2.1 compliance** with security best practices
|
|
8
|
+
- **PKCE (Proof Key for Code Exchange)** for secure authorization
|
|
9
|
+
- **Automatic server discovery** via `.well-known` endpoints
|
|
10
|
+
- **Client ID Metadata Documents** and **pre-registered credentials**; dynamic client registration (RFC 7591) remains as a fallback but is deprecated since MCP 2026-07-28
|
|
11
|
+
- **Token refresh** and automatic token management
|
|
12
|
+
- **Per-authorization-server credentials and tokens** (MCP 2026-07-28): store pre-registered credentials with
|
|
13
|
+
`registration_type: 'pre_registered'` **and the `issuer:` of the authorization server that issued them**
|
|
14
|
+
(or under `provider.client_registration_key(issuer)`); a stored record without a type counts as a dynamic
|
|
15
|
+
registration and is redone for a new authorization server, and tokens are kept per authorization server too
|
|
16
|
+
- **Resource parameter implementation** (RFC 8707) for proper token audience binding
|
|
17
|
+
- **Pluggable storage** for tokens and client credentials
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
### Basic Usage
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require 'mcp_client'
|
|
25
|
+
|
|
26
|
+
# Create an OAuth-enabled HTTP server
|
|
27
|
+
server = MCPClient::OAuthClient.create_http_server(
|
|
28
|
+
server_url: 'https://api.example.com/mcp',
|
|
29
|
+
redirect_uri: 'http://localhost:8080/callback',
|
|
30
|
+
scope: 'mcp:read mcp:write'
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Check if authorization is needed
|
|
34
|
+
unless MCPClient::OAuthClient.valid_token?(server)
|
|
35
|
+
# Start OAuth flow
|
|
36
|
+
auth_url = MCPClient::OAuthClient.start_oauth_flow(server)
|
|
37
|
+
puts "Please visit: #{auth_url}"
|
|
38
|
+
|
|
39
|
+
# After user authorization, complete the flow. Forward the callback's
|
|
40
|
+
# `iss` (RFC 9207): an authorization server that advertises
|
|
41
|
+
# `authorization_response_iss_parameter_supported` sends it, and a
|
|
42
|
+
# completion without it is refused rather than redeeming the code.
|
|
43
|
+
# token = MCPClient::OAuthClient.complete_oauth_flow(server, code, state, iss: iss)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Use the server normally
|
|
47
|
+
server.connect
|
|
48
|
+
tools = server.list_tools
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Manual OAuth Provider
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# Create OAuth provider directly for more control
|
|
55
|
+
oauth_provider = MCPClient::Auth::OAuthProvider.new(
|
|
56
|
+
server_url: 'https://api.example.com/mcp',
|
|
57
|
+
redirect_uri: 'http://localhost:8080/callback',
|
|
58
|
+
scope: 'mcp:read mcp:write'
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Start authorization flow
|
|
62
|
+
auth_url = oauth_provider.start_authorization_flow
|
|
63
|
+
|
|
64
|
+
# Complete flow after user authorization; pass the callback's iss parameter
|
|
65
|
+
# so the response is checked against the authorization server (RFC 9207)
|
|
66
|
+
token = oauth_provider.complete_authorization_flow(code, state, iss: iss)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## OAuth Flow Steps
|
|
70
|
+
|
|
71
|
+
The implementation follows the standard OAuth 2.1 authorization code flow with PKCE:
|
|
72
|
+
|
|
73
|
+
1. **Server Discovery**: Protected Resource Metadata is authoritative (RFC 9728). On a `401` the client
|
|
74
|
+
parses the `resource_metadata` parameter from the `WWW-Authenticate` header (a legacy `resource`
|
|
75
|
+
parameter is accepted as a fallback); otherwise it probes the `.well-known` URLs in priority order.
|
|
76
|
+
- **Protected Resource Metadata (RFC 9728 §3.1)** is path-aware: the well-known segment is inserted
|
|
77
|
+
between host and path, then a root fallback is tried.
|
|
78
|
+
- Example: `https://api.example.com/mcp` →
|
|
79
|
+
`https://api.example.com/.well-known/oauth-protected-resource/mcp`, then
|
|
80
|
+
`https://api.example.com/.well-known/oauth-protected-resource`
|
|
81
|
+
- **Authorization Server Metadata (RFC 8414 §3.1 + OpenID Connect Discovery)**: for an issuer with a
|
|
82
|
+
path, the well-known segment is *inserted* (not appended); both `oauth-authorization-server` and
|
|
83
|
+
`openid-configuration` forms are tried in priority order.
|
|
84
|
+
- The discovered protected-resource `resource` is validated against the server host (confused-deputy
|
|
85
|
+
protection), and every authorization-server endpoint must use HTTPS (plain HTTP is allowed on the
|
|
86
|
+
loopback interface — `localhost`, a `*.localhost` name, or any spelling of 127.0.0.0/8 or
|
|
87
|
+
`::1` — for local development).
|
|
88
|
+
- Both documents must carry what their RFCs make REQUIRED, and are read against the types those
|
|
89
|
+
RFCs give their fields. A protected resource document (RFC 9728 §2) must carry `resource`; an
|
|
90
|
+
authorization server document (RFC 8414 §2) must carry `issuer`, `authorization_endpoint`
|
|
91
|
+
and `token_endpoint`. A document that omits one is refused at discovery with a
|
|
92
|
+
`ConnectionError` and is neither cached nor used for scope resolution — rather than being
|
|
93
|
+
accepted, cached, and then crashing with a `URI::InvalidURIError` inside
|
|
94
|
+
`start_authorization_flow` or `complete_authorization_flow`, after a dynamic client
|
|
95
|
+
registration has already created a client at the authorization server.
|
|
96
|
+
- A protected resource document (RFC 9728 §2) has a string `resource` and arrays of strings for
|
|
97
|
+
`authorization_servers` and `scopes_supported`; an authorization server document (RFC 8414 §2)
|
|
98
|
+
has string endpoints (`issuer`, `authorization_endpoint`, `token_endpoint`,
|
|
99
|
+
`registration_endpoint`) and arrays of strings for `scopes_supported`,
|
|
100
|
+
`response_types_supported`, `grant_types_supported` and `code_challenge_methods_supported`. A
|
|
101
|
+
field of any other type refuses the document with a `ConnectionError`, so
|
|
102
|
+
`{"scopes_supported": "mcp:read"}` is a reported discovery failure rather than a `NoMethodError`
|
|
103
|
+
out of `start_authorization_flow`, and a `code_challenge_methods_supported` of `"S256 plain"` is
|
|
104
|
+
never mistaken for PKCE support (a String answers `include?("S256")`; an authorization server
|
|
105
|
+
that supports no PKCE would otherwise read as one that does). The two boolean advertisements —
|
|
106
|
+
`client_id_metadata_document_supported` and `authorization_response_iss_parameter_supported` —
|
|
107
|
+
keep their fail-closed reading instead. The same standard applies to a record read back from a
|
|
108
|
+
storage backend that persists plain hashes: PKCE support requires an array, and a
|
|
109
|
+
`scopes_supported` that is not one contributes no scopes.
|
|
110
|
+
2. **Client Registration**: Use pre-registered credentials or a Client ID Metadata Document; fall back to dynamic registration (deprecated) when the authorization server offers nothing else
|
|
111
|
+
- A registration response names a client only when it is a JSON object whose `client_id` is a
|
|
112
|
+
non-empty string (RFC 7591 Section 3.2.1). A `201` without one registered nothing, so it raises a
|
|
113
|
+
`ConnectionError` and the flow ends *before* the browser is opened, rather than sending the user
|
|
114
|
+
to the authorization endpoint with an empty `client_id`.
|
|
115
|
+
- Every other field is read against the type RFC 7591 gives it: `client_secret`,
|
|
116
|
+
`token_endpoint_auth_method`, `scope`, `client_name`, `client_uri`, `logo_uri`, `tos_uri`,
|
|
117
|
+
`policy_uri` and `application_type` are strings, `client_id_issued_at` and
|
|
118
|
+
`client_secret_expires_at` are integers, and `redirect_uris`, `grant_types`, `response_types`
|
|
119
|
+
and `contacts` are arrays of strings. A field of any other type fails the registration with the
|
|
120
|
+
same `ConnectionError` — `{"client_id": "c", "redirect_uris": "http://localhost:1/cb"}` is a
|
|
121
|
+
reported registration failure, not a `NoMethodError` — and a `redirect_uris` the server omits or
|
|
122
|
+
echoes back empty falls back to the redirect URI the registration asked for.
|
|
123
|
+
- An array of strings is not yet an array of redirect URIs: every element must be one a callback
|
|
124
|
+
could actually arrive on AND one MCP 2026-07-28 allows ("All redirect URIs MUST be either
|
|
125
|
+
`localhost` or use HTTPS") — an HTTPS URL with a host, a plain-HTTP URL on the loopback
|
|
126
|
+
interface (the callback server `BrowserOAuth` runs), or an RFC 8252 §7.1 private-use scheme
|
|
127
|
+
(`com.example.app:/oauth2redirect`, `com.example.app://oauth`), and in no case a fragment (RFC
|
|
128
|
+
6749 §3.1.2). So `{"redirect_uris": [""]}`, `["/cb"]`, `["javascript:alert(1)"]`,
|
|
129
|
+
`["data:text/html,…"]`, `["http:"]` and `["http://app.example.com/cb"]` are a reported
|
|
130
|
+
registration failure rather than a browser opened at them.
|
|
131
|
+
- The registered `token_endpoint_auth_method` is what the client then authenticates with. RFC 7591
|
|
132
|
+
§2 makes `client_secret_basic` the default, so a registration response that issues a
|
|
133
|
+
`client_secret` and names no method is recorded as a confidential client using it — not as
|
|
134
|
+
`none`, which would mean the secret is never sent and every token request goes out
|
|
135
|
+
unauthenticated. A registration without a secret stays a public client (`none`).
|
|
136
|
+
- **Registration state is per authorization server** (MCP 2026-07-28, SEP-2352). A `client_id`
|
|
137
|
+
(and any secret with it) is issued by one authorization server and means nothing at another, and
|
|
138
|
+
one MCP server can be served by more than one over its lifetime, so credentials are stored twice:
|
|
139
|
+
under the resource URL — the registration *in use*, where every backend and every record written
|
|
140
|
+
by an earlier version already keeps it — and under a key of the issuing authorization server,
|
|
141
|
+
`provider.client_registration_key(issuer)`. Two authorization servers behind one MCP server
|
|
142
|
+
therefore each keep their own registration: configuring the second no longer replaces the first,
|
|
143
|
+
and coming back to the first finds its registration instead of reporting "these credentials
|
|
144
|
+
belong to another authorization server". A host that pre-registers credentials with several
|
|
145
|
+
authorization servers can seed them directly:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
storage.set_client_info(provider.client_registration_key('https://as-a.example.com'), creds_a)
|
|
149
|
+
storage.set_client_info(provider.client_registration_key('https://as-b.example.com'), creds_b)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Credentials a host pre-registers **must say which authorization server issued them** — either by that
|
|
153
|
+
key, or with `issuer:` on the `ClientInfo`. A `client_id` and its secret are issued by one authorization
|
|
154
|
+
server, and whichever server discovery returns first does not establish that: a resource that starts
|
|
155
|
+
advertising another authorization server would otherwise have the credentials relabelled as its, and the
|
|
156
|
+
code exchange would post the secret registered with the first server to the second. Credentials that name
|
|
157
|
+
none are kept — this client cannot re-create them — but authorization (and refresh) raises a
|
|
158
|
+
`ConnectionError` naming both ways to bind them, and nothing is sent to any authorization server until
|
|
159
|
+
one is used. A Client ID Metadata Document id is portable across authorization servers and needs no
|
|
160
|
+
issuer; a dynamic registration this client made is bound by the authorization server cached alongside it,
|
|
161
|
+
and retired when nothing was cached.
|
|
162
|
+
|
|
163
|
+
Credentials the host pre-registered with the authorization server in use come first, ahead of both a
|
|
164
|
+
portable Client ID Metadata Document id and a dynamic registration this client made for itself — the MCP
|
|
165
|
+
client registration priority order — and a dynamic registration never overwrites them under that server's
|
|
166
|
+
key.
|
|
167
|
+
|
|
168
|
+
**Tokens are kept the same way.** MCP 2026-07-28 makes registration state — "client credentials, tokens" —
|
|
169
|
+
per authorization server, so a token is written under the resource URL (the token in use) *and* under
|
|
170
|
+
`client_registration_key(issuer)`. When the authorization server changes, the previous server's token is
|
|
171
|
+
set aside under its own key instead of being thrown away, and it is picked up again if that server becomes
|
|
172
|
+
the one in use, so a resource served by two authorization servers over its lifetime does not send the user
|
|
173
|
+
through consent again for a grant nobody revoked. A token this client *retired* — a 401 challenge naming
|
|
174
|
+
another authorization server, or a record that cannot say where it came from — is removed wherever it is
|
|
175
|
+
kept, and no token is presented to an authorization server other than the one bound to it. The per-server
|
|
176
|
+
copy is best-effort, like the registration copy; only the slot in use is essential.
|
|
177
|
+
|
|
178
|
+
Nothing is migrated or moved: the resource-URL slot keeps answering as before, and a per-issuer
|
|
179
|
+
copy is written the first time a record is used or stored. When an authorization server change
|
|
180
|
+
discards the registration in use, the per-issuer record is deliberately kept — that registration
|
|
181
|
+
is still valid at the server that made it.
|
|
182
|
+
|
|
183
|
+
The resource-URL slot is the slot a host writes to, so it wins over the per-issuer copy whenever
|
|
184
|
+
the authorization server in use can be asked to accept what it holds: a client secret rotated
|
|
185
|
+
there is used by the next authorization request *and* by the next refresh, rather than being
|
|
186
|
+
overruled by the older copy. The one exception is a portable Client ID Metadata Document id,
|
|
187
|
+
which answers for every authorization server: credentials pre-registered with the server in use
|
|
188
|
+
come first, as the MCP client registration priority order says they should.
|
|
189
|
+
|
|
190
|
+
The write to that slot is the one a flow depends on — `complete_authorization_flow` reads it to
|
|
191
|
+
redeem the code — so a backend that cannot persist it raises a `ConnectionError` before the
|
|
192
|
+
browser is opened, instead of returning an authorization URL whose callback then reports
|
|
193
|
+
"Missing PKCE or client info" after the user has already consented. The per-issuer copy stays
|
|
194
|
+
best-effort: a backend that refuses that key logs at debug and the flow continues.
|
|
195
|
+
3. **Authorization**: Redirect user to authorization server with PKCE parameters
|
|
196
|
+
- The authorization endpoint's own query string is retained and the authorization parameters are
|
|
197
|
+
appended to it (RFC 6749 §3.1), so an endpoint of `https://as.example/authorize?tenant=acme`
|
|
198
|
+
keeps its `tenant`.
|
|
199
|
+
4. **Token Exchange**: Exchange authorization code for access token using PKCE verifier
|
|
200
|
+
- A token response carries a credential only when it is a JSON object whose `access_token` is a
|
|
201
|
+
non-empty string (RFC 6749 Section 5.1). Anything else — `200 {}`, `200 []`, `200 null`,
|
|
202
|
+
`{"access_token": ["x"]}` — is a protocol error, not a credential: the exchange raises a
|
|
203
|
+
`ConnectionError` and nothing is stored.
|
|
204
|
+
- Every other field is read against the type RFC 6749 Section 5.1 gives it: `expires_in` is an
|
|
205
|
+
integer and `scope` a string. A field of any other type fails the exchange with the same
|
|
206
|
+
`ConnectionError`, so `token_type: ["Bearer"]` never reaches the `Authorization` header and
|
|
207
|
+
`expires_in: "3600"` never reaches a `Time`. A `null` field reads as an absent one — including
|
|
208
|
+
`token_type`, which is REQUIRED and therefore fails the response either way.
|
|
209
|
+
- `access_token` and `token_type` must carry bytes an HTTP header can hold: both are non-empty
|
|
210
|
+
strings free of control characters, so a token containing CR/LF (`"fresh\r\nX-Injected: 1"`)
|
|
211
|
+
is refused instead of being stored and split into two header lines.
|
|
212
|
+
- `token_type` must moreover name a type this client can present. RFC 6749 §7.1: "the client MUST
|
|
213
|
+
NOT use an access token if it does not understand the token type". A bearer token is presented
|
|
214
|
+
as it stands (RFC 6750 §2.1, and what MCP requires); a `DPoP` or `mac` token needs a proof or a
|
|
215
|
+
signature this client does not produce, so putting its bytes behind `Authorization: DPoP` would
|
|
216
|
+
present a credential in a way its authorization server never authorized. Such a response fails
|
|
217
|
+
the exchange with a `ConnectionError` and fails a refresh (keeping the still-valid token), and a
|
|
218
|
+
stored record of such a type presents no token at all. The comparison is case-insensitive
|
|
219
|
+
(`bearer`, `BEARER`). An **absent** `token_type` is refused too: RFC 6749 §5.1 makes it REQUIRED
|
|
220
|
+
and defines no default — "Bearer" is one value it may carry (RFC 6750), not what its absence
|
|
221
|
+
means — and §7.1 forbids using a token whose type the client does not understand, which a client
|
|
222
|
+
that was told no type does not. So `200 {"access_token": "x"}` fails the exchange and fails a
|
|
223
|
+
refresh (keeping the still-valid token) rather than going out as `Authorization: Bearer x`.
|
|
224
|
+
- `refresh_token` is a credential too, so it is bytes or nothing: `refresh_token: ""` fails the
|
|
225
|
+
response rather than being persisted over the refresh token the client already holds.
|
|
226
|
+
- A confidential client presents its credentials the way the authorization server registered them:
|
|
227
|
+
`client_secret_basic` (RFC 7591's default) sends them in an `Authorization: Basic` header,
|
|
228
|
+
form-urlencoded before they are base64-encoded (RFC 6749 §2.3.1); `client_secret_post` sends the
|
|
229
|
+
secret in the request body; a public client sends neither. A method this client cannot present
|
|
230
|
+
(`private_key_jwt`, `client_secret_jwt`) is logged and the request is made without client
|
|
231
|
+
authentication rather than with the secret in a header the server did not ask for. The same
|
|
232
|
+
applies to a token refresh.
|
|
233
|
+
5. **Token Usage**: Include access token in MCP requests via `Authorization` header
|
|
234
|
+
6. **Token Refresh**: Automatically refresh tokens when they expire
|
|
235
|
+
- A refresh response that carries no such `access_token`, whose fields have the wrong JSON types,
|
|
236
|
+
whose credentials are unusable bytes, or that is not JSON at all, is a failed refresh: the
|
|
237
|
+
still-valid token stays in storage and keeps being presented rather than being replaced by a
|
|
238
|
+
bare `Bearer `, by the `to_s` of whatever JSON arrived, or by an exception raised out of the
|
|
239
|
+
request path.
|
|
240
|
+
- The same checks are made of what storage reads back, since a backend answers with whatever it
|
|
241
|
+
was given: a record whose `access_token` or `token_type` is missing, empty, of another type or
|
|
242
|
+
carrying control bytes presents no token at all (and a new authorization flow starts) instead
|
|
243
|
+
of crashing while the `Authorization` header is built.
|
|
244
|
+
- A refresh is two events with a gap between them: the request goes to the authorization server
|
|
245
|
+
the token came from, and the response arrives at a client whose authorization server may have
|
|
246
|
+
changed meanwhile (updated protected-resource metadata, a `401` challenge, another provider
|
|
247
|
+
sharing the storage). The issuer check made before the request is therefore made again over the
|
|
248
|
+
response: a refreshed token from a server that is no longer this resource's is neither stored —
|
|
249
|
+
where it would overwrite the token of the server now in use, or resurrect one a challenge had
|
|
250
|
+
just retired — nor handed to the caller. `access_token` returns `nil` and the next call presents
|
|
251
|
+
the current server's token.
|
|
252
|
+
|
|
253
|
+
## Configuration Options
|
|
254
|
+
|
|
255
|
+
### Server Creation Options
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
server = MCPClient::OAuthClient.create_http_server(
|
|
259
|
+
server_url: 'https://api.example.com/mcp', # MCP server URL (required)
|
|
260
|
+
redirect_uri: 'http://localhost:8080/callback', # OAuth redirect URI
|
|
261
|
+
scope: 'mcp:read mcp:write', # OAuth scope
|
|
262
|
+
endpoint: '/rpc', # JSON-RPC endpoint
|
|
263
|
+
headers: {}, # Additional HTTP headers
|
|
264
|
+
read_timeout: 30, # Request timeout
|
|
265
|
+
retries: 3, # Retry attempts
|
|
266
|
+
retry_backoff: 1, # Retry backoff
|
|
267
|
+
name: 'my-server', # Server name
|
|
268
|
+
logger: Logger.new($stdout), # Logger instance
|
|
269
|
+
storage: custom_storage # Custom storage backend
|
|
270
|
+
)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### OAuth Provider Options
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
oauth_provider = MCPClient::Auth::OAuthProvider.new(
|
|
277
|
+
server_url: 'https://api.example.com/mcp', # MCP server URL (required)
|
|
278
|
+
redirect_uri: 'http://localhost:8080/callback', # OAuth redirect URI
|
|
279
|
+
scope: 'mcp:read mcp:write', # OAuth scope
|
|
280
|
+
logger: Logger.new($stdout), # Logger instance
|
|
281
|
+
storage: custom_storage # Custom storage backend
|
|
282
|
+
)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`server_url=` retargets an existing provider at another MCP server. Everything the provider learned
|
|
286
|
+
about the previous server in this process — the discovered authorization server metadata it keeps as a
|
|
287
|
+
fallback for storage backends that do not persist it, the memoized `supported_scopes`, and any adopted,
|
|
288
|
+
pending or refused `401` challenge — is forgotten, so the new server is discovered from scratch rather
|
|
289
|
+
than answered with the previous server's endpoints. Retirement markers for tokens are keyed by the
|
|
290
|
+
issuer they were retired for, not by the server URL, so they survive the change: bytes retired at an
|
|
291
|
+
authorization server stay retired for every MCP server behind it.
|
|
292
|
+
|
|
293
|
+
## Storage Backends
|
|
294
|
+
|
|
295
|
+
By default, the OAuth provider uses in-memory storage. For production use, implement a custom storage backend:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
class DatabaseTokenStorage
|
|
299
|
+
def get_token(key)
|
|
300
|
+
# Return MCPClient::Auth::Token or nil.
|
|
301
|
+
#
|
|
302
|
+
# The key is an opaque string, exactly as for get_client_info: the MCP
|
|
303
|
+
# server URL for the token in use, and provider.client_registration_key(
|
|
304
|
+
# issuer) for the token kept for one authorization server.
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def set_token(key, token)
|
|
308
|
+
# Store token. A nil token means "forget it": remove the record rather
|
|
309
|
+
# than serializing nil (a hash-persisting backend would otherwise store
|
|
310
|
+
# an empty hash, which reads back as a token without bytes).
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def get_client_info(key)
|
|
314
|
+
# Return MCPClient::Auth::ClientInfo or nil.
|
|
315
|
+
#
|
|
316
|
+
# The key is an opaque string: the MCP server URL for the registration in
|
|
317
|
+
# use, and provider.client_registration_key(issuer) — the server URL plus
|
|
318
|
+
# the authorization server's issuer — for the registration state of one
|
|
319
|
+
# authorization server (MCP 2026-07-28, SEP-2352). A backend that treats
|
|
320
|
+
# the key as a string (a Hash, a column, a hashed filename) needs no
|
|
321
|
+
# change; one that parses it as a URL should not.
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def set_client_info(key, client_info)
|
|
325
|
+
# Store client info. A nil client_info means "forget it": remove the
|
|
326
|
+
# record rather than serializing nil, for the same reason as set_token
|
|
327
|
+
# (a record whose client_id is not a non-empty string reads back as no
|
|
328
|
+
# client at all, and the next flow registers a new one).
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Implement other required methods:
|
|
332
|
+
# get_server_metadata, set_server_metadata
|
|
333
|
+
# get_pkce, set_pkce, delete_pkce
|
|
334
|
+
# get_state, set_state, delete_state
|
|
335
|
+
#
|
|
336
|
+
# The PKCE record is the per-request record of one authorization request:
|
|
337
|
+
# it carries the code verifier, the expected issuer, the client id, the
|
|
338
|
+
# redirect URI and the `state` (MCP 2026-07-28). set_state/get_state keep
|
|
339
|
+
# answering as before, but the state is checked against the PKCE record
|
|
340
|
+
# too, so a backend must round-trip the record's fields (Hash-persisting
|
|
341
|
+
# backends get them from PKCE#to_h) rather than only the verifier.
|
|
342
|
+
|
|
343
|
+
# Optional (MCP 2026-07-28): called when the authorization server behind
|
|
344
|
+
# a resource changes, since a token from the previous one must not be
|
|
345
|
+
# reused. Without it, set_token(key, nil) is attempted; a backend that
|
|
346
|
+
# accepts neither is logged and the token is ignored instead. It is called
|
|
347
|
+
# with the server-URL key for the token in use and, when a token is
|
|
348
|
+
# retired outright, with client_registration_key(issuer) for the copy kept
|
|
349
|
+
# for that authorization server; on a plain authorization server change
|
|
350
|
+
# that copy is kept, so returning to that server finds its token.
|
|
351
|
+
def delete_token(key)
|
|
352
|
+
# Remove the stored token
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Optional (MCP 2026-07-28): called when a dynamic registration made with
|
|
356
|
+
# the previous authorization server is discarded. Without it,
|
|
357
|
+
# set_client_info(server_url, nil) is attempted. Only the registration in
|
|
358
|
+
# use (the server-URL key) is deleted; the per-issuer record of the
|
|
359
|
+
# authorization server that made it is kept, since that registration is
|
|
360
|
+
# still valid there.
|
|
361
|
+
def delete_client_info(server_url)
|
|
362
|
+
# Remove the stored client registration
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Use custom storage
|
|
367
|
+
storage = DatabaseTokenStorage.new
|
|
368
|
+
server = MCPClient::OAuthClient.create_http_server(
|
|
369
|
+
server_url: 'https://api.example.com/mcp',
|
|
370
|
+
storage: storage
|
|
371
|
+
)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
## Data Models
|
|
375
|
+
|
|
376
|
+
### Token
|
|
377
|
+
|
|
378
|
+
```ruby
|
|
379
|
+
token = MCPClient::Auth::Token.new(
|
|
380
|
+
access_token: 'abc123',
|
|
381
|
+
token_type: 'Bearer',
|
|
382
|
+
expires_in: 3600,
|
|
383
|
+
scope: 'mcp:read mcp:write',
|
|
384
|
+
refresh_token: 'refresh123'
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Check token status
|
|
388
|
+
token.expired? # Boolean
|
|
389
|
+
token.expires_soon? # Boolean (within 5 minutes)
|
|
390
|
+
token.to_header # "Bearer abc123"
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
A record read back from a storage backend that persists plain hashes carries whatever was written
|
|
394
|
+
there, so the expiry is validated before it is used: `expires_in` must be a number and `expires_at`
|
|
395
|
+
a readable instant (a `Time`, or the ISO 8601 string `to_h` writes). An expiry that is neither is
|
|
396
|
+
not "no expiry" — read that way a mangled lifetime would make a token that never expires — so the
|
|
397
|
+
record reads as expired, through a storage round trip too, and is refreshed or re-authorized
|
|
398
|
+
instead of raising a `TypeError` out of `access_token`.
|
|
399
|
+
|
|
400
|
+
A record that carries an `expires_at` is answered by that `expires_at` alone. `expires_in` is the
|
|
401
|
+
lifetime of a token at the moment it was *issued* (RFC 6749 §5.1), and a record read back from
|
|
402
|
+
storage was not issued now, so it is never substituted for a stored expiry that cannot be read:
|
|
403
|
+
`Token.from_h(expires_in: 3600, expires_at: 'not a time')` — the shape `to_h` persists, with the
|
|
404
|
+
one field this client depends on mangled — reads as expired, not as an hour of fresh lifetime.
|
|
405
|
+
|
|
406
|
+
### Client Metadata
|
|
407
|
+
|
|
408
|
+
```ruby
|
|
409
|
+
metadata = MCPClient::Auth::ClientMetadata.new(
|
|
410
|
+
redirect_uris: ['http://localhost:8080/callback'],
|
|
411
|
+
token_endpoint_auth_method: 'none',
|
|
412
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
413
|
+
response_types: ['code'],
|
|
414
|
+
scope: 'mcp:read mcp:write'
|
|
415
|
+
)
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
`token_endpoint_auth_method` decides how the client authenticates at the token endpoint:
|
|
419
|
+
`'none'` (a public client — what this library registers as), `'client_secret_post'` (the secret in
|
|
420
|
+
the request body) or `'client_secret_basic'` (the credentials in an `Authorization: Basic` header,
|
|
421
|
+
and RFC 7591's default for a registration that names no method). Pre-registered credentials should
|
|
422
|
+
carry the method the authorization server expects; one stored with a secret and `'none'` is
|
|
423
|
+
presented with HTTP Basic, since a secret and "no authentication" authenticate nowhere.
|
|
424
|
+
|
|
425
|
+
### Server Metadata
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
metadata = MCPClient::Auth::ServerMetadata.new(
|
|
429
|
+
issuer: 'https://auth.example.com',
|
|
430
|
+
authorization_endpoint: 'https://auth.example.com/authorize',
|
|
431
|
+
token_endpoint: 'https://auth.example.com/token',
|
|
432
|
+
registration_endpoint: 'https://auth.example.com/register',
|
|
433
|
+
code_challenge_methods_supported: ['S256'] # advertised PKCE methods (RFC 8414)
|
|
434
|
+
)
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
## Error Handling
|
|
438
|
+
|
|
439
|
+
OAuth-related errors are raised as `MCPClient::Errors::ConnectionError`:
|
|
440
|
+
|
|
441
|
+
```ruby
|
|
442
|
+
begin
|
|
443
|
+
server.connect
|
|
444
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
445
|
+
if e.message.include?('OAuth authorization required')
|
|
446
|
+
# Start OAuth flow
|
|
447
|
+
auth_url = MCPClient::OAuthClient.start_oauth_flow(server)
|
|
448
|
+
# Handle authorization...
|
|
449
|
+
else
|
|
450
|
+
# Handle other connection errors
|
|
451
|
+
puts "Connection failed: #{e.message}"
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
## Security Considerations
|
|
457
|
+
|
|
458
|
+
This implementation follows OAuth 2.1 security best practices:
|
|
459
|
+
|
|
460
|
+
- **PKCE is mandatory** for all authorization code flows. The client uses `S256` and **verifies the
|
|
461
|
+
authorization server's `code_challenge_methods_supported`**: it refuses to proceed if the field is
|
|
462
|
+
omitted, if it is not an array of strings (a String would answer `include?("S256")` for a server
|
|
463
|
+
that supports no PKCE at all), or if the array does not contain `S256`.
|
|
464
|
+
- **State parameter** is used to prevent CSRF attacks
|
|
465
|
+
- **Resource parameter** (RFC 8707) ensures token audience binding — sent in both the authorization
|
|
466
|
+
and token requests as the canonical server URI
|
|
467
|
+
- **Confused-deputy protection**: the protected-resource metadata `resource` is validated against the
|
|
468
|
+
server host before its advertised authorization server is trusted
|
|
469
|
+
- **HTTPS is enforced** on all discovered authorization-server endpoints (authorization, token, and
|
|
470
|
+
registration), with a loopback exception (`localhost`, `*.localhost`, 127.0.0.0/8, `::1`, in
|
|
471
|
+
any spelling) for local development
|
|
472
|
+
- **RFC 9207 `iss` fails closed**: `authorization_response_iss_parameter_supported` is a JSON
|
|
473
|
+
boolean; a document (or a persisted record) carrying anything else — `"true"`, `1`, `{}` — says
|
|
474
|
+
nothing this client can act on and is read as "advertised", so a callback without `iss` is
|
|
475
|
+
refused rather than accepted from a server that may well send one
|
|
476
|
+
- **Peer text is sanitized** before it reaches a log line or an exception message (which
|
|
477
|
+
`BrowserOAuth` renders on its error page): response bodies and error descriptions are stripped of
|
|
478
|
+
control characters and bounded, and a body that is not JSON is reported by position and size
|
|
479
|
+
(`malformed JSON, at line 1 column 1, 26 byte body`) rather than by the bytes the parser choked on.
|
|
480
|
+
The sanitizers are total — bytes that are not valid UTF-8 (a raw response body, an
|
|
481
|
+
`error_description=%FF` a callback carries, the undecodable fragment a JSON parser quotes back) are
|
|
482
|
+
replaced rather than raising `ArgumentError` out of the rescue path that was reporting them
|
|
483
|
+
- **Peer bytes are made decodable before they are parsed**, not only before they are printed.
|
|
484
|
+
`String#gsub`, `String#match`, `Regexp#match?`, `String#split` and `String#strip` all raise
|
|
485
|
+
`ArgumentError: invalid byte sequence in UTF-8`, so every place that reads a peer's bytes as text
|
|
486
|
+
— the `unauthorized_client` body matched for a `redirect_uri` mismatch, the `WWW-Authenticate`
|
|
487
|
+
header masked and matched for its `Bearer` challenge segment (in the provider and in the HTTP
|
|
488
|
+
transports), the callback query string percent-decoded by `CGI.unescape` — scrubs them first.
|
|
489
|
+
A token endpoint `400` with an undecodable `error_description`, a `401`/`403` challenge with an
|
|
490
|
+
undecodable parameter, and a callback of `?code=%FF&state=%FE` all surface as the authorization
|
|
491
|
+
error they are, not as an `ArgumentError` from the code that was reading them
|
|
492
|
+
- **A callback parameter may appear once**: RFC 6749 §3.1 forbids a request or response parameter
|
|
493
|
+
more than once, precisely because the readers of a query string disagree about which value counts —
|
|
494
|
+
a `Hash` takes the last, other parsers take the first. `BrowserOAuth` refuses a callback that
|
|
495
|
+
repeats any parameter, so `?iss=attacker&iss=recorded` (or a repeated `state` or `code`) is an
|
|
496
|
+
error page rather than a flow that validates the recorded value and acts on the attacker's
|
|
497
|
+
- **An access token is only ever presented as the type it was issued as**: `token_type` is REQUIRED
|
|
498
|
+
(RFC 6749 §5.1, which defines no default) and must be `Bearer` (§7.1 — "the client MUST NOT use an
|
|
499
|
+
access token if it does not understand the token type"), so a `DPoP` or `mac` token — and a
|
|
500
|
+
response that names no type at all — is refused where it is issued and where it is read back
|
|
501
|
+
instead of going out as a bearer credential without the proof its type requires
|
|
502
|
+
- **A refresh and a code exchange are both re-checked against the authorization server in use when
|
|
503
|
+
the response arrives**, not only when the request is sent, so a response that crosses an
|
|
504
|
+
authorization server change is neither presented nor written over the token of the server now in
|
|
505
|
+
use — and a late code exchange no longer deletes the pending authorization request another flow
|
|
506
|
+
started meanwhile
|
|
507
|
+
- **An authorization request is one record**: the `state`, the PKCE verifier, the expected issuer,
|
|
508
|
+
the client id and the redirect URI are stored together (MCP 2026-07-28 requires the issuer to be
|
|
509
|
+
associated with "the same per-request record used to store the PKCE code verifier (and the `state`
|
|
510
|
+
value, if used)"), and the callback's `state` is checked against that record — so two flows sharing
|
|
511
|
+
one storage backend cannot interleave their writes until one flow's state names the other's request
|
|
512
|
+
- **Scopes accumulate across step-ups**: re-authorizing after an `insufficient_scope` challenge asks
|
|
513
|
+
for the union of the scopes already requested and the ones the challenge names, so acquiring
|
|
514
|
+
`files:write` does not give up `files:read`
|
|
515
|
+
- **Credentials never reach a log** at any level: the `Authorization` header is not logged even
|
|
516
|
+
truncated, and the browser callback logs the request path without the query string that carries
|
|
517
|
+
`code=`
|
|
518
|
+
- **A redirect URI must be one a callback can arrive on, and one MCP allows**: an HTTPS URL with a
|
|
519
|
+
host, a plain-HTTP URL on the loopback interface, or an RFC 8252 §7.1 private-use scheme
|
|
520
|
+
(`com.example.app:/cb`, `com.example.app://cb`), and never with a fragment (RFC 6749 §3.1.2) — so a
|
|
521
|
+
dynamic registration cannot send the browser to `javascript:alert(1)`, `data:text/html,…`, a bare
|
|
522
|
+
`http:`, or `http://app.example.com/callback`, which MCP 2026-07-28 "Communication Security"
|
|
523
|
+
forbids ("All redirect URIs MUST be either `localhost` or use HTTPS"). The same rule applies to the
|
|
524
|
+
`redirect_uri` the provider is configured with, which now raises an `ArgumentError` rather than
|
|
525
|
+
being registered
|
|
526
|
+
- **Client credentials go out the way they were registered**: `client_secret_basic` (RFC 7591's
|
|
527
|
+
default when a registration names no method) in an `Authorization: Basic` header, form-urlencoded
|
|
528
|
+
before base64 (RFC 6749 §2.3.1); `client_secret_post` in the body; nothing for a public client or
|
|
529
|
+
for a method this client cannot present
|
|
530
|
+
- **Secure token storage** guidelines should be followed
|
|
531
|
+
|
|
532
|
+
## Examples
|
|
533
|
+
|
|
534
|
+
See `examples/oauth_example.rb` for a complete working example.
|
|
535
|
+
|
|
536
|
+
## Testing
|
|
537
|
+
|
|
538
|
+
Run OAuth-related tests:
|
|
539
|
+
|
|
540
|
+
```bash
|
|
541
|
+
bundle exec rspec spec/lib/mcp_client/auth_spec.rb
|
|
542
|
+
bundle exec rspec spec/lib/mcp_client/auth/oauth_provider_spec.rb
|
|
543
|
+
bundle exec rspec spec/lib/mcp_client/oauth_client_spec.rb
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
## Compliance
|
|
547
|
+
|
|
548
|
+
This implementation conforms to:
|
|
549
|
+
|
|
550
|
+
- [OAuth 2.1 (IETF Draft)](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/)
|
|
551
|
+
- [OAuth 2.0 Authorization Server Metadata (RFC 8414)](https://tools.ietf.org/html/rfc8414)
|
|
552
|
+
- [OAuth 2.0 Dynamic Client Registration (RFC 7591)](https://tools.ietf.org/html/rfc7591)
|
|
553
|
+
- [OAuth 2.0 Protected Resource Metadata (RFC 9728)](https://tools.ietf.org/html/rfc9728)
|
|
554
|
+
- [Resource Indicators for OAuth 2.0 (RFC 8707)](https://tools.ietf.org/html/rfc8707)
|
|
555
|
+
- [MCP Authorization Specification](https://spec.modelcontextprotocol.io/specification/protocol/authorization/)
|