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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0705c6df1b589bdf048b8922a92c957125f75afd0ce3ba832ef71b99a9cbe87a
4
- data.tar.gz: 486d2c9891e107acaf545a62d1845e0f3d843c634d526d80ee8d12c8f9e3bde7
3
+ metadata.gz: c8a27fd18f6a563bff801f562f4696e2432b0349e395b90cefe29d104e387ac1
4
+ data.tar.gz: c68c742a3fb269d8cdbf275745c749004827f3a9caefb9d25a585fd00ee90d62
5
5
  SHA512:
6
- metadata.gz: f766e8b97d5d6ec634fb2e95b000b154e0ce00a0f25011de9a33bd40531d2151ce727e5c3958894f1f79cab8294242441020b6f9184dd8ddf4df828430d4ad2d
7
- data.tar.gz: 4a9bf198454ce1d8a9cb4b8940551faf7ddaac5962248b10b20e5b095bb46d32c1a1a642042da70b98d47a5832c3eb22aa09f9df05d54c40cf3ca1003e92824d
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/)