manceps 1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f524ef8b916adcac4f46f0f382f973b59ab047091b841feeadf5e1691cdb8634
4
+ data.tar.gz: 7708c36c00d55478545f953f762584f99f8d660705e167a5046bbed0818bdee3
5
+ SHA512:
6
+ metadata.gz: 9965075ca2b50283f9a5a1b08b922f32be4b74519828103d5c174b163e15b5acf328a54d9b03e3bacd02a54d6766316dfec285ff78290140f33e03e3cffc8311
7
+ data.tar.gz: 82a066ba382c508fca8b80772f4d78acaaf7009b21f1f210a2c1e1305ebc2f1c662c898cee7e2fb2112767e111534a1e91c3f96b552472db031fe349a0991503
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to Manceps are documented here.
4
+
5
+ ## [1.0.0] - 2026-04-06
6
+
7
+ First public release. A production-grade Ruby client for the Model Context Protocol (MCP).
8
+
9
+ ### Features
10
+
11
+ - **Streamable HTTP transport** with persistent connections via httpx
12
+ - **stdio transport** for local MCP servers (subprocess communication over stdin/stdout)
13
+ - **Auto-detect transport** from URL vs command
14
+ - **Authentication**: Bearer token, API key header, OAuth token support with auto-refresh
15
+ - **Tools**: list, call, streaming calls, structured output support
16
+ - **Resources**: list, read, templates
17
+ - **Prompts**: list, get with arguments
18
+ - **Notifications**: register handlers, subscribe to resource updates, cancel requests
19
+ - **Elicitation**: handle server requests for additional user input
20
+ - **Tasks** (experimental): list, get, cancel, await with polling
21
+ - **Resilience**: automatic retry with exponential backoff, session recovery on 404
22
+ - **Pagination**: automatic cursor-based pagination for list operations
23
+ - **Protocol negotiation**: targets MCP 2025-11-25, falls back to 2025-06-18 and 2025-03-26
24
+ - **Configuration**: client name, version, timeouts, supported protocol versions
25
+ - **Full error hierarchy**: ConnectionError, TimeoutError, ProtocolError, AuthenticationError, SessionExpiredError, ToolError
26
+
27
+ ### Requirements
28
+
29
+ - Ruby >= 3.4.0
30
+ - httpx >= 1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Obie Fernandez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,325 @@
1
+ # Manceps
2
+
3
+ A Ruby client for the [Model Context Protocol](https://modelcontextprotocol.io) (MCP).
4
+
5
+ From Latin *manceps* -- one who takes in hand (contractor, acquirer). From *manus* (hand) + *capere* (to take).
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem "manceps"
12
+ ```
13
+
14
+ Or install directly:
15
+
16
+ ```
17
+ gem install manceps
18
+ ```
19
+
20
+ Requires Ruby >= 3.4.0.
21
+
22
+ ## Quick Start
23
+
24
+ ```ruby
25
+ require "manceps"
26
+
27
+ # HTTP server with bearer auth
28
+ Manceps::Client.open("https://mcp.example.com/mcp", auth: Manceps::Auth::Bearer.new(ENV["MCP_TOKEN"])) do |client|
29
+ client.tools.each { |t| puts "#{t.name}: #{t.description}" }
30
+
31
+ result = client.call_tool("search_documents", query: "quarterly report")
32
+ puts result.text
33
+ end
34
+
35
+ # stdio server (local process)
36
+ Manceps::Client.open("npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]) do |client|
37
+ contents = client.read_resource("file:///tmp/hello.txt")
38
+ puts contents.text
39
+ end
40
+ ```
41
+
42
+ The block form connects, yields the client, and disconnects on exit -- even if an exception is raised.
43
+
44
+ ## Transports
45
+
46
+ ### Streamable HTTP
47
+
48
+ The primary MCP transport. Uses [httpx](https://honeyryderchuck.gitlab.io/httpx/) for persistent connections -- MCP servers bind sessions to TCP connections, so connection reuse is required.
49
+
50
+ ```ruby
51
+ client = Manceps::Client.new("https://mcp.example.com/mcp", auth: auth)
52
+ ```
53
+
54
+ ### stdio
55
+
56
+ Spawns a local subprocess and communicates via newline-delimited JSON over stdin/stdout.
57
+
58
+ ```ruby
59
+ client = Manceps::Client.new("npx", args: ["-y", "@modelcontextprotocol/server-memory"])
60
+
61
+ # With environment variables
62
+ client = Manceps::Client.new("mm-mcp", env: { "MM_TOKEN" => "...", "MM_URL" => "..." })
63
+ ```
64
+
65
+ The transport auto-detects: HTTP(S) URLs use Streamable HTTP, everything else uses stdio.
66
+
67
+ ## Authentication
68
+
69
+ ### Bearer Token
70
+
71
+ ```ruby
72
+ auth = Manceps::Auth::Bearer.new("your-token")
73
+ ```
74
+
75
+ ### API Key Header
76
+
77
+ ```ruby
78
+ auth = Manceps::Auth::ApiKeyHeader.new("x-api-key", "your-key")
79
+ ```
80
+
81
+ ### OAuth 2.1 (Experimental)
82
+
83
+ RFC 8414 discovery, RFC 7591 dynamic registration, PKCE, and automatic token refresh. Works but not yet tested against a wide range of authorization servers.
84
+
85
+ ```ruby
86
+ # If you already have tokens
87
+ auth = Manceps::Auth::OAuth.new(
88
+ access_token: "...",
89
+ refresh_token: "...",
90
+ token_url: "https://auth.example.com/token",
91
+ client_id: "...",
92
+ expires_at: Time.now + 3600,
93
+ on_token_refresh: ->(tokens) { save_tokens(tokens) }
94
+ )
95
+
96
+ # Full discovery + authorization flow
97
+ discovery = Manceps::Auth::OAuth.discover("https://mcp.example.com", redirect_uri: "http://localhost:3000/callback")
98
+ pkce = Manceps::Auth::OAuth.generate_pkce
99
+
100
+ url = Manceps::Auth::OAuth.authorize_url(
101
+ authorization_url: discovery.authorization_url,
102
+ client_id: discovery.client_id,
103
+ redirect_uri: "http://localhost:3000/callback",
104
+ state: SecureRandom.hex(16),
105
+ scopes: discovery.scopes,
106
+ code_challenge: pkce[:challenge]
107
+ )
108
+ # Redirect user to `url`, then exchange the code:
109
+
110
+ tokens = Manceps::Auth::OAuth.exchange_code(
111
+ token_url: discovery.token_url,
112
+ client_id: discovery.client_id,
113
+ client_secret: discovery.client_secret,
114
+ code: params[:code],
115
+ redirect_uri: "http://localhost:3000/callback",
116
+ code_verifier: pkce[:verifier]
117
+ )
118
+ ```
119
+
120
+ Token refresh happens automatically when a token is within 5 minutes of expiry. The `on_token_refresh` callback fires after each refresh so you can persist the new tokens.
121
+
122
+ ### No Auth
123
+
124
+ The default. Useful for local servers:
125
+
126
+ ```ruby
127
+ client = Manceps::Client.new("http://localhost:3000/mcp")
128
+ ```
129
+
130
+ ## Tools
131
+
132
+ ```ruby
133
+ # List available tools
134
+ tools = client.tools
135
+ tools.each do |tool|
136
+ puts "#{tool.title || tool.name}: #{tool.description}"
137
+ puts " Input: #{tool.input_schema}"
138
+ puts " Output: #{tool.output_schema}" if tool.output_schema # structured output (2025-06-18+)
139
+ end
140
+
141
+ # Call a tool
142
+ result = client.call_tool("get_weather", location: "New York")
143
+ result.text # joined text content
144
+ result.content # Array<Content>
145
+ result.error? # true if server flagged an error
146
+ result.structured_content # parsed structured output (when tool declares outputSchema)
147
+ result.structured? # true if structured content present
148
+
149
+ # Stream a long-running tool call
150
+ client.call_tool_streaming("analyze_data", dataset: "large.csv") do |event|
151
+ puts "Progress: #{event}"
152
+ end
153
+ ```
154
+
155
+ ## Resources
156
+
157
+ ```ruby
158
+ # List resources
159
+ resources = client.resources
160
+ resources.each { |r| puts "#{r.uri}: #{r.title || r.name}" }
161
+
162
+ # List resource templates
163
+ templates = client.resource_templates
164
+ templates.each { |t| puts "#{t.uri_template}: #{t.title || t.name}" }
165
+
166
+ # Read a resource
167
+ contents = client.read_resource("file:///project/src/main.rs")
168
+ puts contents.text
169
+ ```
170
+
171
+ ## Prompts
172
+
173
+ ```ruby
174
+ # List prompts
175
+ prompts = client.prompts
176
+ prompts.each do |p|
177
+ puts "#{p.name}: #{p.description}"
178
+ p.arguments.each { |a| puts " #{a.name} (required: #{a.required?})" }
179
+ end
180
+
181
+ # Get a prompt
182
+ result = client.get_prompt("code_review", code: "def hello; end")
183
+ result.messages.each { |m| puts "#{m.role}: #{m.text}" }
184
+ ```
185
+
186
+ ## Configuration
187
+
188
+ ```ruby
189
+ Manceps.configure do |c|
190
+ c.client_name = "MyApp" # default: "Manceps"
191
+ c.client_version = "1.0.0" # default: Manceps::VERSION
192
+ c.protocol_version = "2025-11-25" # default: "2025-11-25"
193
+ c.request_timeout = 60 # default: 30 (seconds)
194
+ c.connect_timeout = 15 # default: 10 (seconds)
195
+ c.client_description = "My app" # optional, sent in clientInfo
196
+ c.supported_versions = ["2025-11-25", "2025-06-18", "2025-03-26"] # for negotiation
197
+ end
198
+ ```
199
+
200
+ ## Error Handling
201
+
202
+ All errors inherit from `Manceps::Error`:
203
+
204
+ ```
205
+ Manceps::Error
206
+ Manceps::ConnectionError # transport-level failures
207
+ Manceps::TimeoutError # request or connect timeout
208
+ Manceps::ProtocolError # JSON-RPC error (has #code, #data)
209
+ Manceps::AuthenticationError # 401, failed OAuth flows
210
+ Manceps::SessionExpiredError # server invalidated the session (404)
211
+ Manceps::ToolError # tool invocation failed (has #result)
212
+ ```
213
+
214
+ ```ruby
215
+ begin
216
+ result = client.call_tool("risky_operation", id: 42)
217
+ rescue Manceps::SessionExpiredError
218
+ client.connect # re-establish session
219
+ retry
220
+ rescue Manceps::ProtocolError => e
221
+ puts "RPC error #{e.code}: #{e.message}"
222
+ end
223
+ ```
224
+
225
+ ## Why Manceps?
226
+
227
+ **Persistent connections.** MCP servers bind sessions to TCP connections. Manceps uses httpx to keep connections alive across requests, which most HTTP libraries don't do by default.
228
+
229
+ **Auth-first.** Bearer, API key, and OAuth 2.1 (experimental) are built in, not bolted on.
230
+
231
+ **No LLM coupling.** Pure protocol client. No `to_openai_tools()` or framework integrations -- use it with anything.
232
+
233
+ **Extracted from production.** Built and tested under real MCP load, not just spec examples.
234
+
235
+ **Full 2025-11-25 spec.** Protocol version negotiation, elicitation, tasks, structured tool output, `MCP-Protocol-Version` header -- not just the basics.
236
+
237
+ ## Notifications
238
+
239
+ Register handlers for server-initiated messages:
240
+
241
+ ```ruby
242
+ client.on("notifications/tools/list_changed") { puts "Tools changed!" }
243
+ client.on("notifications/resources/updated") { |params| puts "Updated: #{params['uri']}" }
244
+
245
+ # Subscribe to resource updates
246
+ client.subscribe_resource("file:///project/config.yml")
247
+
248
+ # Cancel a long-running request
249
+ client.cancel_request(request_id, reason: "User cancelled")
250
+
251
+ # Listen for notifications (blocking)
252
+ client.listen # dispatches to registered handlers
253
+ ```
254
+
255
+ ## Elicitation
256
+
257
+ Handle server requests for additional user input during tool calls:
258
+
259
+ ```ruby
260
+ client.on_elicitation do |elicitation|
261
+ puts "Server asks: #{elicitation.message}"
262
+ puts "Schema: #{elicitation.requested_schema}"
263
+
264
+ # Respond with user input
265
+ Manceps::Elicitation.accept({ "name" => "Alice", "confirm" => true })
266
+ # Or decline/cancel:
267
+ # Manceps::Elicitation.decline
268
+ # Manceps::Elicitation.cancel
269
+ end
270
+ ```
271
+
272
+ Elicitation capability is automatically declared during initialization when a handler is registered.
273
+
274
+ ## Tasks (Experimental)
275
+
276
+ Track long-running operations:
277
+
278
+ ```ruby
279
+ # List tasks
280
+ client.tasks.each { |t| puts "#{t.id}: #{t.status}" }
281
+
282
+ # Get a specific task
283
+ task = client.get_task("task-123")
284
+ task.completed? # => false
285
+ task.running? # => true
286
+
287
+ # Poll until done
288
+ task = client.await_task("task-123", interval: 2, timeout: 60)
289
+ puts task.result
290
+
291
+ # Cancel a task
292
+ client.cancel_task("task-123")
293
+ ```
294
+
295
+ ## Resilience
296
+
297
+ Automatic retry with exponential backoff on connection failures:
298
+
299
+ ```ruby
300
+ # Connect retries up to max_retries (default: 3)
301
+ client = Manceps::Client.new("https://mcp.example.com/mcp", auth: auth, max_retries: 5)
302
+
303
+ # Requests auto-retry once on session expiry (re-initializes session)
304
+ client.call_tool("search", query: "test") # retries transparently on 404
305
+
306
+ # Check connection health
307
+ client.ping # => true/false
308
+
309
+ # Manual reconnect
310
+ client.reconnect!
311
+ ```
312
+
313
+ ## Protocol Version
314
+
315
+ Manceps targets MCP protocol **2025-11-25** by default. It negotiates with the server during initialization and supports fallback to `2025-06-18` and `2025-03-26`.
316
+
317
+ After initialization, the `MCP-Protocol-Version` header is included on all HTTP requests per the spec.
318
+
319
+ ## License
320
+
321
+ MIT. See [LICENSE](LICENSE) for details.
322
+
323
+ ---
324
+
325
+ Author: [Obie Fernandez](https://github.com/obie)
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ module Auth
5
+ # Authenticates requests with a custom API key header.
6
+ class ApiKeyHeader
7
+ def initialize(header_name, key)
8
+ @header_name = header_name.downcase
9
+ @key = key
10
+ end
11
+
12
+ def apply(headers)
13
+ headers[@header_name] = @key
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ module Auth
5
+ # Authenticates requests with a Bearer token.
6
+ class Bearer
7
+ def initialize(token)
8
+ @token = token
9
+ end
10
+
11
+ def apply(headers)
12
+ headers['authorization'] = "Bearer #{@token}"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ module Auth
5
+ # No-op auth strategy for unauthenticated connections.
6
+ class None
7
+ def apply(headers)
8
+ # no-op
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+ require 'securerandom'
6
+ require 'uri'
7
+ require 'json'
8
+
9
+ module Manceps
10
+ module Auth
11
+ # OAuth 2.1 authentication with discovery, PKCE, and token refresh.
12
+ class OAuth
13
+ Discovery = Struct.new(
14
+ :authorization_url,
15
+ :token_url,
16
+ :registration_endpoint,
17
+ :client_id,
18
+ :client_secret,
19
+ :scopes,
20
+ keyword_init: true
21
+ )
22
+
23
+ attr_reader :access_token, :refresh_token, :expires_at
24
+
25
+ def initialize(
26
+ access_token:,
27
+ refresh_token: nil,
28
+ token_url: nil,
29
+ client_id: nil,
30
+ client_secret: nil,
31
+ expires_at: nil,
32
+ on_token_refresh: nil
33
+ )
34
+ @access_token = access_token
35
+ @refresh_token = refresh_token
36
+ @token_url = token_url
37
+ @client_id = client_id
38
+ @client_secret = client_secret
39
+ @expires_at = expires_at
40
+ @on_token_refresh = on_token_refresh
41
+ @mutex = Mutex.new
42
+ end
43
+
44
+ def apply(headers)
45
+ refresh_if_needed!
46
+ headers['authorization'] = "Bearer #{@access_token}"
47
+ end
48
+
49
+ # Fetch OAuth Authorization Server Metadata (RFC 8414) and optionally
50
+ # perform Dynamic Client Registration (RFC 7591).
51
+ def self.discover(server_url, redirect_uri:, client_name: 'Manceps')
52
+ server_uri = URI.parse(server_url)
53
+ port_suffix = [80, 443].include?(server_uri.port) ? '' : ":#{server_uri.port}"
54
+ well_known = "#{server_uri.scheme}://#{server_uri.host}#{port_suffix}/.well-known/oauth-authorization-server"
55
+
56
+ http = HTTPX.with(timeout: { connect_timeout: 10, request_timeout: 30 })
57
+ metadata = fetch_json(http.get(well_known), 'OAuth discovery')
58
+
59
+ discovery = Discovery.new(
60
+ authorization_url: metadata['authorization_endpoint'],
61
+ token_url: metadata['token_endpoint'],
62
+ registration_endpoint: metadata['registration_endpoint'],
63
+ scopes: metadata['scopes_supported']
64
+ )
65
+
66
+ register_client(http, discovery, redirect_uri, client_name)
67
+ discovery
68
+ end
69
+
70
+ def self.register_client(http, discovery, redirect_uri, client_name)
71
+ reg_endpoint = discovery.registration_endpoint
72
+ return if reg_endpoint.nil? || reg_endpoint.empty?
73
+
74
+ reg_response = http.post(
75
+ reg_endpoint,
76
+ headers: { 'content-type' => 'application/json' },
77
+ body: JSON.generate({
78
+ client_name: client_name,
79
+ redirect_uris: [redirect_uri],
80
+ grant_types: %w[authorization_code refresh_token],
81
+ response_types: ['code'],
82
+ token_endpoint_auth_method: 'client_secret_post'
83
+ })
84
+ )
85
+
86
+ reg_data = fetch_json(reg_response, 'Client registration')
87
+ unless reg_data['client_id']
88
+ raise Manceps::AuthenticationError,
89
+ "Client registration failed: #{reg_data['error']}"
90
+ end
91
+
92
+ discovery.client_id = reg_data['client_id']
93
+ discovery.client_secret = reg_data['client_secret']
94
+ end
95
+
96
+ def self.fetch_json(response, context)
97
+ if response.status >= 400
98
+ raise Manceps::AuthenticationError,
99
+ "#{context} failed (HTTP #{response.status})"
100
+ end
101
+
102
+ JSON.parse(response.body.to_s)
103
+ rescue JSON::ParserError
104
+ raise Manceps::AuthenticationError,
105
+ "#{context}: invalid response (not JSON): #{response.body.to_s[0..200]}"
106
+ end
107
+
108
+ # Build authorization URL for user redirect
109
+ def self.authorize_url(authorization_url:, client_id:, redirect_uri:, state:, scopes: nil, code_challenge: nil)
110
+ params = {
111
+ 'response_type' => 'code',
112
+ 'client_id' => client_id,
113
+ 'redirect_uri' => redirect_uri,
114
+ 'state' => state
115
+ }
116
+ params['scope'] = Array(scopes).join(' ') if !scopes.nil? && !Array(scopes).empty?
117
+ if code_challenge
118
+ params['code_challenge'] = code_challenge
119
+ params['code_challenge_method'] = 'S256'
120
+ end
121
+
122
+ "#{authorization_url}?#{URI.encode_www_form(params)}"
123
+ end
124
+
125
+ # Exchange authorization code for tokens
126
+ def self.exchange_code(token_url:, client_id:, code:, redirect_uri:, client_secret: nil, code_verifier: nil)
127
+ body = {
128
+ 'grant_type' => 'authorization_code',
129
+ 'code' => code,
130
+ 'redirect_uri' => redirect_uri,
131
+ 'client_id' => client_id
132
+ }
133
+ body['client_secret'] = client_secret if !client_secret.nil? && !client_secret.empty?
134
+ body['code_verifier'] = code_verifier if !code_verifier.nil? && !code_verifier.empty?
135
+
136
+ http = HTTPX.with(timeout: { connect_timeout: 10, request_timeout: 30 })
137
+ response = http.post(
138
+ token_url,
139
+ headers: { 'content-type' => 'application/x-www-form-urlencoded' },
140
+ body: URI.encode_www_form(body)
141
+ )
142
+
143
+ data = fetch_json(response, 'Token exchange')
144
+ unless data['access_token']
145
+ raise Manceps::AuthenticationError,
146
+ "Token exchange failed: #{data['error_description'] || data['error'] || 'no access_token'}"
147
+ end
148
+
149
+ data
150
+ end
151
+
152
+ # PKCE helpers (RFC 7636)
153
+ def self.generate_pkce
154
+ verifier = SecureRandom.urlsafe_base64(32)
155
+ challenge = Base64.urlsafe_encode64(
156
+ OpenSSL::Digest::SHA256.digest(verifier), padding: false
157
+ )
158
+ { verifier: verifier, challenge: challenge }
159
+ end
160
+
161
+ private
162
+
163
+ def refresh_if_needed!
164
+ return unless token_expiring_soon? && @refresh_token && @token_url
165
+
166
+ @mutex.synchronize do
167
+ return unless token_expiring_soon?
168
+
169
+ perform_token_refresh
170
+ end
171
+ end
172
+
173
+ def perform_token_refresh
174
+ body = { 'grant_type' => 'refresh_token', 'refresh_token' => @refresh_token, 'client_id' => @client_id }
175
+ body['client_secret'] = @client_secret if !@client_secret.nil? && !@client_secret.empty?
176
+
177
+ http = HTTPX.with(timeout: { connect_timeout: 10, request_timeout: 30 })
178
+ response = http.post(
179
+ @token_url,
180
+ headers: { 'content-type' => 'application/x-www-form-urlencoded' },
181
+ body: URI.encode_www_form(body)
182
+ )
183
+
184
+ data = self.class.fetch_json(response, 'Token refresh')
185
+ unless data['access_token']
186
+ raise Manceps::AuthenticationError,
187
+ "Token refresh failed: #{data['error'] || 'no access_token in response'}"
188
+ end
189
+
190
+ @access_token = data['access_token']
191
+ @refresh_token = data['refresh_token'] if data['refresh_token']
192
+ @expires_at = data['expires_in'] ? Time.now + data['expires_in'].to_i : nil
193
+
194
+ @on_token_refresh&.call(access_token: @access_token, refresh_token: @refresh_token, expires_at: @expires_at)
195
+ end
196
+
197
+ def token_expiring_soon?
198
+ @expires_at && @expires_at < Time.now + 300
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ # Exponential backoff calculator for retry logic.
5
+ class Backoff
6
+ def initialize(base: 1, max: 30, multiplier: 2, jitter: true)
7
+ @base = base
8
+ @max = max
9
+ @multiplier = multiplier
10
+ @jitter = jitter
11
+ @attempts = 0
12
+ end
13
+
14
+ def next_delay
15
+ delay = [@base * (@multiplier**@attempts), @max].min
16
+ delay *= rand(0.5..1.0) if @jitter
17
+ @attempts += 1
18
+ delay
19
+ end
20
+
21
+ def reset
22
+ @attempts = 0
23
+ end
24
+ end
25
+ end