ruby_llm-mcp 1.0.0 → 1.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a516cd88de84929d95dbaaea467780ba499dc8362001a02d00ac0c8672107506
4
- data.tar.gz: b852e278be156403a32454a00fa4ad450491d219d6243d9629098680576056d2
3
+ metadata.gz: d19acd8152ac9ce9f7991474d6ff590175512f2e5f6524acf11e1e011e4a312c
4
+ data.tar.gz: 4ad593f7c6d7c5d6c5f225c7c6466226e0ff71f412c53e4542af2a65b9b59299
5
5
  SHA512:
6
- metadata.gz: a63c3650a2db47782d7db7eae32feec794302e63f2fe9967a584d1253fb42de75dfe004c87ba8758e9c09d06be856dc0d645183d0fc5b80e18a0cb294c5cda68
7
- data.tar.gz: a89b6bffe3d03b95a9b2734c39f8b6152c7a07f945a9fe70f6eb3f3cb5423939a4fa7e16de2db94e5b01acdc82fde6b0d0d7eadcf4ce94ebd91a6654fa65565f
6
+ metadata.gz: 2b012fa00eaf0b132de3f716cd0e20045ef6188327dbb35770794bd0d6955d5f3396854718d8ae12ca5a09752aa5ce26bb7b347f4241ca7a4d243ca8c4ac6097
7
+ data.tar.gz: 7c0684175c0032fdac8a5ec1a5e1acb09d16fb5bb4fc81b64b12984a30e2e74ff96e6929dcc8d16780820febe871d151d1da41e00d7abf526c29f7110cb80143
@@ -123,7 +123,8 @@ module RubyLLM
123
123
  def parse_registered_metadata(data, redirect_uri)
124
124
  ClientMetadata.new(
125
125
  redirect_uris: data["redirect_uris"] || [redirect_uri],
126
- token_endpoint_auth_method: data["token_endpoint_auth_method"] || "none",
126
+ token_endpoint_auth_method: data["token_endpoint_auth_method"] ||
127
+ (data["client_secret"] ? "client_secret_post" : "none"),
127
128
  grant_types: data["grant_types"] || %w[authorization_code refresh_token],
128
129
  response_types: data["response_types"] || ["code"],
129
130
  scope: data["scope"],
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "base64"
4
+ require "uri"
5
+
3
6
  module RubyLLM
4
7
  module MCP
5
8
  module Auth
@@ -26,8 +29,8 @@ module RubyLLM
26
29
  registered_redirect_uri = client_info.metadata.redirect_uris.first
27
30
  params = build_auth_code_params(client_info, code, pkce, registered_redirect_uri, server_url)
28
31
 
29
- response = post_token_exchange(server_metadata, params)
30
- response = retry_if_redirect_mismatch(response, server_metadata, params, registered_redirect_uri)
32
+ response = post_token_exchange(server_metadata, params, client_info)
33
+ response = retry_if_redirect_mismatch(response, server_metadata, params, registered_redirect_uri, client_info)
31
34
 
32
35
  validate_token_response!(response, "Token exchange")
33
36
  parse_token_response(response)
@@ -45,12 +48,11 @@ module RubyLLM
45
48
  params = {
46
49
  grant_type: "client_credentials",
47
50
  client_id: client_info.client_id,
48
- client_secret: client_info.client_secret,
49
51
  scope: scope,
50
52
  resource: server_url
51
53
  }.compact
52
54
 
53
- response = post_token_exchange(server_metadata, params)
55
+ response = post_token_exchange(server_metadata, params, client_info)
54
56
  validate_token_response!(response, "Token exchange")
55
57
  parse_token_response(response)
56
58
  end
@@ -67,7 +69,7 @@ module RubyLLM
67
69
  logger.debug("Refreshing access token")
68
70
 
69
71
  params = build_refresh_params(client_info, token, server_url)
70
- response = post_token_refresh(server_metadata, params)
72
+ response = post_token_refresh(server_metadata, params, client_info)
71
73
 
72
74
  # Return nil on error responses
73
75
  return nil if response.is_a?(HTTPX::ErrorResponse)
@@ -100,7 +102,7 @@ module RubyLLM
100
102
  # @param server_url [String] MCP server URL
101
103
  # @return [Hash] token exchange parameters
102
104
  def build_auth_code_params(client_info, code, pkce, redirect_uri, server_url)
103
- params = {
105
+ {
104
106
  grant_type: "authorization_code",
105
107
  code: code,
106
108
  redirect_uri: redirect_uri,
@@ -108,9 +110,6 @@ module RubyLLM
108
110
  code_verifier: pkce.code_verifier,
109
111
  resource: server_url
110
112
  }
111
-
112
- add_client_secret_if_needed(params, client_info)
113
- params
114
113
  end
115
114
 
116
115
  # Build parameters for token refresh
@@ -119,49 +118,65 @@ module RubyLLM
119
118
  # @param server_url [String] MCP server URL
120
119
  # @return [Hash] refresh parameters
121
120
  def build_refresh_params(client_info, token, server_url)
122
- params = {
121
+ {
123
122
  grant_type: "refresh_token",
124
123
  refresh_token: token.refresh_token,
125
124
  client_id: client_info.client_id,
126
125
  resource: server_url
127
126
  }
128
-
129
- add_client_secret_if_needed(params, client_info)
130
- params
131
127
  end
132
128
 
133
- # Add client secret to params if needed
134
- # @param params [Hash] token request parameters
135
- # @param client_info [ClientInfo] client info
136
- def add_client_secret_if_needed(params, client_info)
129
+ # Apply client authentication per RFC 6749 §2.3
130
+ # Supports "client_secret_post" (secret in body), "client_secret_basic" (HTTP Basic),
131
+ # and "none" (public client, no secret sent).
132
+ # @param params [Hash] token request form parameters (mutated in place)
133
+ # @param headers [Hash] HTTP headers (mutated in place)
134
+ # @param client_info [ClientInfo] client info with secret and auth method
135
+ def apply_client_auth!(params, headers, client_info)
137
136
  return unless client_info.client_secret
138
- return unless client_info.metadata.token_endpoint_auth_method == "client_secret_post"
139
137
 
140
- params[:client_secret] = client_info.client_secret
138
+ case client_auth_method(client_info)
139
+ when "client_secret_post"
140
+ params[:client_secret] = client_info.client_secret
141
+ when "client_secret_basic"
142
+ client_id = URI.encode_www_form_component(client_info.client_id)
143
+ client_secret = URI.encode_www_form_component(client_info.client_secret)
144
+ credentials = Base64.strict_encode64("#{client_id}:#{client_secret}")
145
+ headers["Authorization"] = "Basic #{credentials}"
146
+ end
147
+ end
148
+
149
+ # Resolve the client authentication method, including registrations cached by older versions.
150
+ # Older versions stored an omitted method as "none", even when the server returned a secret.
151
+ # @param client_info [ClientInfo] client info with secret and auth method
152
+ # @return [String, nil] effective token endpoint authentication method
153
+ def client_auth_method(client_info)
154
+ auth_method = client_info.metadata&.token_endpoint_auth_method
155
+ return "client_secret_post" if client_info.client_secret && [nil, "none"].include?(auth_method)
156
+
157
+ auth_method
141
158
  end
142
159
 
143
160
  # Post token exchange request
144
161
  # @param server_metadata [ServerMetadata] server metadata
145
162
  # @param params [Hash] form parameters
163
+ # @param client_info [ClientInfo, nil] client info for authentication
146
164
  # @return [HTTPX::Response] HTTP response
147
- def post_token_exchange(server_metadata, params)
148
- http_client.post(
149
- server_metadata.token_endpoint,
150
- headers: { "Content-Type" => "application/x-www-form-urlencoded" },
151
- form: params
152
- )
165
+ def post_token_exchange(server_metadata, params, client_info = nil)
166
+ headers = { "Content-Type" => "application/x-www-form-urlencoded" }
167
+ apply_client_auth!(params, headers, client_info) if client_info
168
+ http_client.post(server_metadata.token_endpoint, headers:, form: params)
153
169
  end
154
170
 
155
171
  # Post token refresh request
156
172
  # @param server_metadata [ServerMetadata] server metadata
157
173
  # @param params [Hash] form parameters
174
+ # @param client_info [ClientInfo, nil] client info for authentication
158
175
  # @return [HTTPX::Response] HTTP response
159
- def post_token_refresh(server_metadata, params)
160
- response = http_client.post(
161
- server_metadata.token_endpoint,
162
- headers: { "Content-Type" => "application/x-www-form-urlencoded" },
163
- form: params
164
- )
176
+ def post_token_refresh(server_metadata, params, client_info = nil)
177
+ headers = { "Content-Type" => "application/x-www-form-urlencoded" }
178
+ apply_client_auth!(params, headers, client_info) if client_info
179
+ response = http_client.post(server_metadata.token_endpoint, headers:, form: params)
165
180
 
166
181
  if response.is_a?(HTTPX::ErrorResponse)
167
182
  logger.warn("Token refresh failed: #{response.error&.message || 'Request failed'}")
@@ -176,8 +191,9 @@ module RubyLLM
176
191
  # @param server_metadata [ServerMetadata] server metadata
177
192
  # @param params [Hash] exchange parameters
178
193
  # @param registered_redirect_uri [String] registered redirect URI
194
+ # @param client_info [ClientInfo] client info for authentication
179
195
  # @return [HTTPX::Response] response (possibly retried)
180
- def retry_if_redirect_mismatch(response, server_metadata, params, registered_redirect_uri)
196
+ def retry_if_redirect_mismatch(response, server_metadata, params, registered_redirect_uri, client_info)
181
197
  # Don't retry on error responses
182
198
  return response if response.is_a?(HTTPX::ErrorResponse)
183
199
  return response if response.status == 200
@@ -188,7 +204,7 @@ module RubyLLM
188
204
 
189
205
  logger.warn("Redirect URI mismatch, retrying with: #{redirect_hint[:expected]}")
190
206
  params[:redirect_uri] = redirect_hint[:expected]
191
- post_token_exchange(server_metadata, params)
207
+ post_token_exchange(server_metadata, params, client_info)
192
208
  end
193
209
 
194
210
  # Validate token response
@@ -5,7 +5,7 @@ module RubyLLM
5
5
  module Handlers
6
6
  # Represents an async response for deferred completion
7
7
  class AsyncResponse
8
- attr_reader :elicitation_id, :state, :result, :error
8
+ attr_reader :elicitation_id
9
9
 
10
10
  VALID_STATES = %i[pending completed rejected cancelled timed_out].freeze
11
11
 
@@ -95,27 +95,27 @@ module RubyLLM
95
95
 
96
96
  # Check if operation is pending
97
97
  def pending?
98
- @state == :pending
98
+ @mutex.synchronize { @state == :pending }
99
99
  end
100
100
 
101
101
  # Check if operation is completed
102
102
  def completed?
103
- @state == :completed
103
+ @mutex.synchronize { @state == :completed }
104
104
  end
105
105
 
106
106
  # Check if operation is rejected
107
107
  def rejected?
108
- @state == :rejected
108
+ @mutex.synchronize { @state == :rejected }
109
109
  end
110
110
 
111
111
  # Check if operation is cancelled
112
112
  def cancelled?
113
- @state == :cancelled
113
+ @mutex.synchronize { @state == :cancelled }
114
114
  end
115
115
 
116
116
  # Check if operation timed out
117
117
  def timed_out?
118
- @state == :timed_out
118
+ @mutex.synchronize { @state == :timed_out }
119
119
  end
120
120
 
121
121
  # Check if operation is finished (any terminal state)
@@ -123,6 +123,18 @@ module RubyLLM
123
123
  !pending?
124
124
  end
125
125
 
126
+ def state
127
+ @mutex.synchronize { @state }
128
+ end
129
+
130
+ def result
131
+ @mutex.synchronize { @result }
132
+ end
133
+
134
+ def error
135
+ @mutex.synchronize { @error }
136
+ end
137
+
126
138
  private
127
139
 
128
140
  # Transition to new state (thread-safe)
@@ -5,8 +5,6 @@ module RubyLLM
5
5
  module Handlers
6
6
  # Promise implementation for async operations
7
7
  class Promise
8
- attr_reader :state, :value, :reason
9
-
10
8
  # Initialize a new promise
11
9
  def initialize
12
10
  @state = :pending
@@ -120,17 +118,17 @@ module RubyLLM
120
118
 
121
119
  # Check if promise is pending
122
120
  def pending?
123
- @state == :pending
121
+ @mutex.synchronize { @state == :pending }
124
122
  end
125
123
 
126
124
  # Check if promise is fulfilled
127
125
  def fulfilled?
128
- @state == :fulfilled
126
+ @mutex.synchronize { @state == :fulfilled }
129
127
  end
130
128
 
131
129
  # Check if promise is rejected
132
130
  def rejected?
133
- @state == :rejected
131
+ @mutex.synchronize { @state == :rejected }
134
132
  end
135
133
 
136
134
  # Check if promise is settled (fulfilled or rejected)
@@ -146,7 +144,7 @@ module RubyLLM
146
144
  # Wait until promise is settled
147
145
  if timeout
148
146
  deadline = Time.now + timeout
149
- while pending?
147
+ while @state == :pending
150
148
  remaining = deadline - Time.now
151
149
  if remaining <= 0
152
150
  raise Timeout::Error, "Promise timed out after #{timeout} seconds"
@@ -155,15 +153,27 @@ module RubyLLM
155
153
  @condition.wait(@mutex, remaining)
156
154
  end
157
155
  else
158
- @condition.wait(@mutex) while pending?
156
+ @condition.wait(@mutex) while @state == :pending
159
157
  end
160
158
 
161
159
  # Return value or raise error
162
- return @value if fulfilled?
163
- raise @reason if rejected?
160
+ return @value if @state == :fulfilled
161
+ raise @reason if @state == :rejected
164
162
  end
165
163
  end
166
164
 
165
+ def state
166
+ @mutex.synchronize { @state }
167
+ end
168
+
169
+ def value
170
+ @mutex.synchronize { @value }
171
+ end
172
+
173
+ def reason
174
+ @mutex.synchronize { @reason }
175
+ end
176
+
167
177
  private
168
178
 
169
179
  # Execute a callback safely
@@ -6,7 +6,7 @@ module RubyLLM
6
6
  # Wraps server-initiated requests to support cancellation.
7
7
  # The operation tracks terminal state so cancellation outcomes are explicit.
8
8
  class CancellableOperation
9
- attr_reader :request_id, :thread, :state
9
+ attr_reader :request_id
10
10
 
11
11
  def initialize(request_id)
12
12
  @request_id = request_id
@@ -61,22 +61,21 @@ module RubyLLM
61
61
  def execute(&)
62
62
  return nil if cancelled?
63
63
 
64
- @mutex.synchronize do
64
+ worker = @mutex.synchronize do
65
65
  return nil if %i[cancelled cancelling].include?(@state)
66
66
 
67
67
  @state = :running
68
- end
69
-
70
- @thread = Thread.new do
71
- Thread.current.abort_on_exception = false
72
- begin
73
- @result = yield
74
- rescue Errors::RequestCancelled, StandardError => e
75
- @error = e
68
+ @thread = Thread.new do
69
+ Thread.current.abort_on_exception = false
70
+ begin
71
+ @result = yield
72
+ rescue Errors::RequestCancelled, StandardError => e
73
+ @error = e
74
+ end
76
75
  end
77
76
  end
78
77
 
79
- @thread.join
78
+ worker.join
80
79
  raise @error if @error && !@error.is_a?(Errors::RequestCancelled)
81
80
 
82
81
  @result
@@ -88,6 +87,14 @@ module RubyLLM
88
87
  @thread = nil
89
88
  end
90
89
  end
90
+
91
+ def state
92
+ @mutex.synchronize { @state }
93
+ end
94
+
95
+ def thread
96
+ @mutex.synchronize { @thread }
97
+ end
91
98
  end
92
99
  end
93
100
  end
@@ -406,6 +406,7 @@ module RubyLLM
406
406
  handle_authorization_challenge(response, request_id, original_message)
407
407
  when 405
408
408
  # Method not allowed - acceptable for some endpoints
409
+ fail_pending_request(request_id, response) if request_id
409
410
  nil
410
411
  when 400...500
411
412
  handle_client_error(response)
@@ -418,6 +419,19 @@ module RubyLLM
418
419
  end
419
420
  end
420
421
 
422
+ def fail_pending_request(request_id, response)
423
+ response_body = response.respond_to?(:body) ? response.body.to_s : "Unknown error"
424
+ error = Errors::TransportError.new(
425
+ code: response.status,
426
+ message: "HTTP request failed: #{response.status} - #{response_body}"
427
+ )
428
+
429
+ @pending_mutex.synchronize do
430
+ queue = @pending_requests.delete(request_id.to_s)
431
+ queue&.push(error)
432
+ end
433
+ end
434
+
421
435
  def handle_success_response(response, request_id, _original_message)
422
436
  content_type = response.respond_to?(:headers) ? response.headers["content-type"] : nil
423
437
 
@@ -479,7 +493,7 @@ module RubyLLM
479
493
  def handle_oauth_authorization_error(response, status_code)
480
494
  response_body = response.respond_to?(:body) ? response.body.to_s : ""
481
495
  error_body = JSON.parse(response_body)
482
- error_message = error_body.dig("error", "message") || "Authorization failed"
496
+ error_message = oauth_error_message(error_body) || "Authorization failed"
483
497
 
484
498
  raise Errors::TransportError.new(
485
499
  code: status_code,
@@ -492,6 +506,17 @@ module RubyLLM
492
506
  )
493
507
  end
494
508
 
509
+ # A 403 body can be either a JSON-RPC error ({ "error" => { "message" => ... } }) or an
510
+ # RFC 6749 OAuth error ({ "error" => "invalid_token", "error_description" => ... })
511
+ def oauth_error_message(error_body)
512
+ return unless error_body.is_a?(Hash)
513
+
514
+ case error_body["error"]
515
+ when Hash then error_body["error"]["message"]
516
+ when String then error_body["error_description"] || error_body["error"]
517
+ end
518
+ end
519
+
495
520
  def handle_json_error_response(response, status_code)
496
521
  response_body = response.respond_to?(:body) ? response.body.to_s : "Unknown error"
497
522
  error_body = JSON.parse(response_body)
@@ -22,6 +22,7 @@ module RubyLLM
22
22
  result
23
23
  else
24
24
  worker.kill # stop the thread (can still have some risk if shared resources)
25
+ worker.join(0.1)
25
26
  raise RubyLLM::MCP::Errors::TimeoutError.new(
26
27
  message: "Request timed out after #{seconds} seconds",
27
28
  request_id: request_id
@@ -8,9 +8,9 @@ module RubyLLM
8
8
  def initialize(annotation)
9
9
  @title = annotation["title"] || ""
10
10
  @read_only_hint = annotation["readOnlyHint"] || false
11
- @destructive_hint = annotation["destructiveHint"] || true
11
+ @destructive_hint = annotation.fetch("destructiveHint", true)
12
12
  @idempotent_hint = annotation["idempotentHint"] || false
13
- @open_world_hint = annotation["openWorldHint"] || true
13
+ @open_world_hint = annotation.fetch("openWorldHint", true)
14
14
  end
15
15
 
16
16
  def to_h
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module MCP
5
- VERSION = "1.0.0"
5
+ VERSION = "1.0.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick Vice
@@ -27,16 +27,22 @@ dependencies:
27
27
  name: json-schema
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - "~>"
30
+ - - ">="
31
31
  - !ruby/object:Gem::Version
32
32
  version: '5.0'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '7'
33
36
  type: :runtime
34
37
  prerelease: false
35
38
  version_requirements: !ruby/object:Gem::Requirement
36
39
  requirements:
37
- - - "~>"
40
+ - - ">="
38
41
  - !ruby/object:Gem::Version
39
42
  version: '5.0'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '7'
40
46
  - !ruby/object:Gem::Dependency
41
47
  name: json_schemer
42
48
  requirement: !ruby/object:Gem::Requirement
@@ -238,7 +244,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
238
244
  - !ruby/object:Gem::Version
239
245
  version: '0'
240
246
  requirements: []
241
- rubygems_version: 4.0.3
247
+ rubygems_version: 4.0.10
242
248
  specification_version: 4
243
249
  summary: A RubyLLM MCP Client
244
250
  test_files: []