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 +7 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE +21 -0
- data/README.md +325 -0
- data/lib/manceps/auth/api_key_header.rb +17 -0
- data/lib/manceps/auth/bearer.rb +16 -0
- data/lib/manceps/auth/none.rb +12 -0
- data/lib/manceps/auth/oauth.rb +202 -0
- data/lib/manceps/backoff.rb +25 -0
- data/lib/manceps/client.rb +278 -0
- data/lib/manceps/content.rb +33 -0
- data/lib/manceps/elicitation.rb +26 -0
- data/lib/manceps/errors.rb +32 -0
- data/lib/manceps/json_rpc.rb +55 -0
- data/lib/manceps/prompt.rb +30 -0
- data/lib/manceps/prompt_result.rb +27 -0
- data/lib/manceps/resource.rb +17 -0
- data/lib/manceps/resource_contents.rb +16 -0
- data/lib/manceps/resource_template.rb +17 -0
- data/lib/manceps/session.rb +43 -0
- data/lib/manceps/sse_parser.rb +64 -0
- data/lib/manceps/task.rb +42 -0
- data/lib/manceps/tool.rb +24 -0
- data/lib/manceps/tool_result.rb +26 -0
- data/lib/manceps/transport/base.rb +36 -0
- data/lib/manceps/transport/stdio.rb +143 -0
- data/lib/manceps/transport/streamable_http.rb +190 -0
- data/lib/manceps/version.rb +5 -0
- data/lib/manceps.rb +66 -0
- metadata +102 -0
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,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
|